import { ProgressTracker } from '@php-wasm/progress';
import { Semaphore } from '@php-wasm/util';
import type { SupportedPHPVersion, UniversalPHP } from '@php-wasm/universal';
import {
	LatestSupportedPHPVersion,
	SupportedPHPVersions,
} from '@php-wasm/universal';
import type { FileReference } from './resources';
import { isResourceReference, Resource } from './resources';
import type { Step, StepDefinition, WriteFileStep } from '../steps';
import * as allStepHandlers from '../steps/handlers';
import type {
	BlueprintV1Declaration,
	ExtraLibrary,
	StreamBundledFile,
	BlueprintV1,
} from './types';
import type { BlueprintBundle } from '../types';
import { logger } from '@php-wasm/logger';

// @TODO: Configure this in the `wp-cli` step, not here.
const { wpCLI, ...otherStepHandlers } = allStepHandlers;
const keyedStepHandlers = {
	...otherStepHandlers,
	'wp-cli': wpCLI,
	importFile: otherStepHandlers.importWxr,
};

/**
 * The JSON schema validator stored in this directory is used to validate
 * the Blueprints and is autogenerated from the Blueprints schema which is
 * autogenerated from TypeScript types.
 *
 * Whenever the types are modified, the schema and validator need to be
 * rebuilt using `nx build playground-blueprints` and then committed to
 * the repository.
 *
 * Unfortunately, it is not auto-rebuilt in `npm run dev` mode as the
 * `dts-bundle-generator` utility we use for type rollyps does not support
 * watching for changes.
 */
import blueprintValidator from '../../../public/blueprint-schema-validator';
import { defaultWpCliPath, defaultWpCliResource } from '../steps/wp-cli';
import type { ErrorObject } from 'ajv';

export class InvalidBlueprintError extends Error {
	public readonly validationErrors?: unknown;

	constructor(message: string, validationErrors?: unknown) {
		super(message);
		this.name = 'InvalidBlueprintError';
		this.validationErrors = validationErrors;
	}
}

/**
 * Error thrown when a single Blueprint step fails during execution.
 *
 * This error carries structured information about the failing step so that
 * consumers (e.g. the Playground UI) do not have to parse human‑readable
 * error messages to understand what went wrong.
 */
export class BlueprintStepExecutionError extends Error {
	public readonly stepNumber: number;
	public readonly step: StepDefinition;
	public readonly messages: string[];

	constructor(options: {
		stepNumber: number;
		step: StepDefinition;
		cause: unknown;
	}) {
		const { stepNumber, step, cause } = options;
		const causeError =
			cause instanceof Error ? cause : new Error(String(cause));
		const baseMessage = `Error when executing the blueprint step #${stepNumber}`;
		const fullMessage = causeError.message
			? `${baseMessage}: ${causeError.message}`
			: baseMessage;

		super(fullMessage, { cause: causeError });
		this.name = 'BlueprintStepExecutionError';
		this.stepNumber = stepNumber;
		this.step = step;
		this.messages = (causeError.message || '')
			.split('\n')
			.map((line) => line.trim())
			.filter(Boolean);
	}
}

export type CompiledV1Step = (php: UniversalPHP) => Promise<void> | void;

export interface CompiledBlueprintV1 {
	/** The requested versions of PHP and WordPress for the blueprint */
	versions: {
		php: SupportedPHPVersion;
		wp: string;
	};
	features: {
		/** Should boot with support for Intl dynamic extension */
		intl: boolean;
		/** Should boot with support for network request via wp_safe_remote_get? */
		networking: boolean;
	};
	extraLibraries: ExtraLibrary[];
	/** The compiled steps for the blueprint */
	run: (playground: UniversalPHP) => Promise<void>;
}

export type OnStepCompleted = (output: any, step: StepDefinition) => any;

export interface CompileBlueprintV1Options {
	/** Optional progress tracker to monitor progress */
	progress?: ProgressTracker;
	/** Optional semaphore to control access to a shared resource */
	semaphore?: Semaphore;
	/** Optional callback with step output */
	onStepCompleted?: OnStepCompleted;
	/** Optional callback with blueprint validation result */
	onBlueprintValidated?: (blueprint: BlueprintV1Declaration) => void;
	/**
	 * Proxy URL to use for cross-origin requests.
	 *
	 * For example, if corsProxy is set to "https://cors.wordpress.net/proxy.php",
	 * then the CORS requests to https://github.com/WordPress/gutenberg.git would actually
	 * be made to https://cors.wordpress.net/proxy.php?https://github.com/WordPress/gutenberg.git.
	 */
	corsProxy?: string;
	/**
	 * A filesystem to use for the blueprint.
	 */
	streamBundledFile?: StreamBundledFile;
	/**
	 * Additional headers to pass to git operations.
	 * A function that returns headers based on the URL being accessed.
	 */
	gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
	/**
	 * Additional steps to add to the blueprint.
	 */
	additionalSteps?: any[];
}

export async function compileBlueprintV1(
	input: BlueprintV1Declaration | BlueprintBundle,
	options: Omit<CompileBlueprintV1Options, 'streamBundledFile'> = {}
): Promise<CompiledBlueprintV1> {
	const finalOptions: CompileBlueprintV1Options = {
		...options,
	};

	let blueprint: BlueprintV1Declaration;
	if (isBlueprintBundle(input)) {
		blueprint = await getBlueprintDeclaration(input);
		finalOptions.streamBundledFile = function (...args: [any]) {
			return input.read(...args);
		};
	} else {
		blueprint = input as BlueprintV1Declaration;
	}

	return compileBlueprintJson(blueprint, finalOptions);
}

export function isBlueprintBundle(input: any): input is BlueprintBundle {
	return input && 'read' in input && typeof input.read === 'function';
}

export async function getBlueprintDeclaration(
	blueprint: BlueprintV1 | BlueprintBundle
): Promise<BlueprintV1Declaration> {
	if (!isBlueprintBundle(blueprint)) {
		return blueprint;
	}
	const blueprintFile = await blueprint.read('blueprint.json');
	const blueprintText = await blueprintFile.text();
	return JSON.parse(blueprintText);
}

/**
 * Compiles Blueprint into a form that can be executed.
 *
 * @param playground The PlaygroundClient to use for the compilation
 * @param blueprint The bBueprint to compile
 * @param options Additional options for the compilation
 * @returns The compiled blueprint
 */
function compileBlueprintJson(
	blueprint: BlueprintV1Declaration,
	{
		progress = new ProgressTracker(),
		semaphore = new Semaphore({ concurrency: 3 }),
		onStepCompleted = () => {},
		onBlueprintValidated = () => {},
		corsProxy,
		streamBundledFile,
		gitAdditionalHeadersCallback,
		additionalSteps,
	}: CompileBlueprintV1Options = {}
): CompiledBlueprintV1 {
	blueprint = structuredClone(blueprint);

	blueprint = {
		...blueprint,
		steps: (blueprint.steps || [])
			.filter(isStepDefinition)
			.filter(isStepStillSupported),
	};

	blueprint.steps = [...(blueprint.steps || []), ...(additionalSteps || [])];

	for (const step of blueprint.steps!) {
		if (!step || typeof step !== 'object') {
			continue;
		}
		// Convert legacy importFile steps to importWxr
		if ((step as any).step === 'importFile') {
			(step as any).step = 'importWxr';
			logger.warn(
				`The "importFile" step is deprecated. Use "importWxr" instead.`
			);
		} else if (
			(step as any)?.step === 'installPlugin' &&
			'pluginZipFile' in step
		) {
			(step as any).pluginData = (step as any).pluginZipFile;
			logger.warn(
				`The "pluginZipFile" option of the "installPlugin" step is deprecated. Use "pluginData" instead.`
			);
		} else if (
			(step as any)?.step === 'installTheme' &&
			'themeZipFile' in step
		) {
			(step as any).themeData = (step as any).themeZipFile;
			logger.warn(
				`The "themeZipFile" option of the "installTheme" step is deprecated. Use "themeData" instead.`
			);
		}
	}

	// Experimental declarative syntax {{{
	if (blueprint.constants) {
		blueprint.steps!.unshift({
			step: 'defineWpConfigConsts',
			consts: blueprint.constants,
		});
	}
	if (blueprint.siteOptions) {
		blueprint.steps!.unshift({
			step: 'setSiteOptions',
			options: blueprint.siteOptions,
		});
	}
	if (blueprint.plugins) {
		// Translate an array of strings into a map of pluginName => true to
		// install the latest version of the plugin from wordpress.org
		const steps = blueprint.plugins
			.map((value) => {
				if (typeof value === 'string') {
					if (value.startsWith('https://')) {
						return {
							resource: 'url',
							url: value,
						} as FileReference;
					} else {
						return {
							resource: 'wordpress.org/plugins',
							slug: value,
						} as FileReference;
					}
				}
				return value;
			})
			.map((resource) => ({
				step: 'installPlugin',
				pluginData: resource,
			})) as StepDefinition[];
		blueprint.steps!.unshift(...steps);
	}

	/**
	 * Prepend a login step to enable Blueprints to override the default login step.
	 */
	if (blueprint.login) {
		blueprint.steps!.unshift({
			step: 'login',
			...(blueprint.login === true
				? { username: 'admin' }
				: blueprint.login),
		});
	}

	/**
	 * Download WP-CLI. {{{
	 * Hardcoding this in the compile() function is a temporary solution
	 * to provide steps with the wp-cli.phar file it needs. Eventually,
	 * each Blueprint step may be able to specify any pre-requisite resources.
	 * Also, wp-cli should only be downloaded if it's not already present.
	 *
	 * The enableMultisite step uses wp-cli to convert the site to a multisite.
	 * The wp-cli step itself depends on WP-CLI.
	 */
	const indexOfStepThatDependsOnWpCli =
		blueprint.steps?.findIndex(
			(step) =>
				typeof step === 'object' &&
				step?.step &&
				['wp-cli', 'enableMultisite'].includes(step.step)
		) ?? -1;
	if (
		blueprint?.extraLibraries?.includes('wp-cli') ||
		indexOfStepThatDependsOnWpCli !== -1
	) {
		const wpCliInstallStep: WriteFileStep<FileReference> = {
			step: 'writeFile',
			data: defaultWpCliResource,
			path: defaultWpCliPath,
		};
		/**
		 * If the blueprint does not have a wp-cli step,
		 * we can install wp-cli as the last step because other steps don't depend
		 * on wp-cli.
		 *
		 * If the blueprint has wp-cli steps,
		 * we need to install wp-cli before running these steps.
		 */
		if (indexOfStepThatDependsOnWpCli === -1) {
			blueprint.steps?.push(wpCliInstallStep);
		} else {
			blueprint.steps?.splice(
				indexOfStepThatDependsOnWpCli,
				0,
				wpCliInstallStep
			);
		}
	}

	/**
	 * Download the WordPress-importer plugin. {{{
	 * Hardcoding this in the compile() function is a temporary solution
	 */
	const importWxrStepIndex = blueprint.steps?.findIndex(
		(step) => typeof step === 'object' && step?.step === 'importWxr'
	);
	if (importWxrStepIndex !== undefined && importWxrStepIndex > -1) {
		blueprint.steps?.splice(importWxrStepIndex, 0, {
			step: 'installPlugin',
			pluginData: {
				resource: 'wordpress.org/plugins',
				slug: 'wordpress-importer',
			},
		});
	}

	const validationResult = validateBlueprint(blueprint);
	if (!validationResult.valid) {
		const { errors } = validationResult;
		const formattedErrors = formatValidationErrors(blueprint, errors);

		throw new InvalidBlueprintError(
			`Invalid Blueprint: The Blueprint does not conform to the schema.\n\n` +
				`Found ${errors.length} validation error(s):\n\n${formattedErrors}\n\n` +
				`Please review your Blueprint and fix these issues. ` +
				`Learn more about the Blueprint format: https://wordpress.github.io/wordpress-playground/blueprints/data-format`,
			errors
		);
	}

	onBlueprintValidated(blueprint);

	const steps = (blueprint.steps || []) as StepDefinition[];
	const totalProgressWeight = steps.reduce(
		(total, step) => total + (step.progress?.weight || 1),
		0
	);
	const compiled = steps.map((step) =>
		compileStep(step, {
			semaphore,
			rootProgressTracker: progress,
			totalProgressWeight,
			corsProxy,
			streamBundledFile,
			gitAdditionalHeadersCallback,
		})
	);

	return {
		versions: {
			php: compileVersion(
				blueprint.preferredVersions?.php,
				SupportedPHPVersions,
				LatestSupportedPHPVersion
			),
			wp: blueprint.preferredVersions?.wp || 'latest',
		},
		features: {
			// Disable intl by default to reduce the transfer size
			intl: blueprint.features?.intl ?? false,
			// Enable network access by default
			networking: blueprint.features?.networking ?? true,
		},
		extraLibraries: blueprint.extraLibraries || [],
		run: async (playground: UniversalPHP) => {
			try {
				// Start resolving resources early
				for (const { resources } of compiled) {
					for (const resource of resources) {
						resource.setPlayground(playground);
						if (resource.isAsync) {
							resource.resolve().catch(() => {
								/**
								 * Catch and ignore the errors.
								 *
								 * If we let them bubble up at this stage, they'll turn into uncaught
								 * rejections and clog the error log.
								 *
								 * Instead, let's wait until a step tries to await the resource resolution
								 * and handle the rejection there.
								 */
							});
						}
					}
				}

				for (const [i, { run, step }] of Object.entries(compiled)) {
					try {
						const result = await run(playground);
						onStepCompleted(result, step);
					} catch (e) {
						const stepNumber = Number(i) + 1;
						throw new BlueprintStepExecutionError({
							stepNumber,
							step,
							cause: e,
						});
					}
				}
			} finally {
				try {
					/**
					 * Use an intermediate redirection step to ensure the login cookies
					 * are set before we redirecting to the landing page.
					 *
					 * @see playground_auto_login_redirect_target in the @wp-playground/wordpress package.
					 */
					const targetUrl = await (
						playground as any
					).pathToInternalUrl(blueprint.landingPage || '/');
					await (playground as any).goTo(
						'/index.php?playground-redirection-handler&next=' +
							encodeURIComponent(targetUrl)
					);
				} catch {
					/**
					 * Redirecting to the landing page is a browser-only feature for now.
					 *
					 * The playground object only exposes the `goTo` method when
					 * it's a Comlink proxy object running in the browser.
					 *
					 * Let's tolerate any errors thrown in other runtimes.
					 *
					 * @TODO: Handle "landingPage" in a PHP plugin to make it work in all environments.
					 */
				}
				progress.finish();
			}
		},
	};
}

function formatValidationErrors(
	blueprint: BlueprintV1Declaration,
	errors: ErrorObject<string, unknown>[]
) {
	return errors
		.map((err, index) => {
			const path = err.instancePath || '/';
			let message = err.message || 'validation failed';

			// For "additional properties" errors, highlight the actual problematic key
			let highlightedSnippet = '';
			if (message.includes('must NOT have additional properties')) {
				// Extract the property name from the error params
				const additionalProperty = (err.params as any)
					?.additionalProperty;
				if (additionalProperty) {
					message = `has unexpected property "${additionalProperty}"`;

					// Try to show the offending key highlighted
					try {
						const pathParts = path.split('/').filter(Boolean);
						let currentValue: any = blueprint;
						for (const part of pathParts) {
							if (
								currentValue &&
								typeof currentValue === 'object'
							) {
								currentValue = currentValue[part];
							}
						}

						if (currentValue && typeof currentValue === 'object') {
							const offendingValue =
								currentValue[additionalProperty];
							const valueStr = JSON.stringify(offendingValue);
							highlightedSnippet = `\n  "${additionalProperty}": ${valueStr}\n  ${'^'.repeat(
								additionalProperty.length + 2
							)} This property is not recognized`;
						}
					} catch {
						// If we can't extract context, that's okay
					}
				}
			} else {
				// For other errors, try to extract the offending value
				try {
					const pathParts = path.split('/').filter(Boolean);
					let currentValue: any = blueprint;
					for (const part of pathParts) {
						if (currentValue && typeof currentValue === 'object') {
							currentValue = currentValue[part];
						}
					}
					if (currentValue !== undefined) {
						const valueStr = JSON.stringify(currentValue, null, 2);
						// Limit snippet length
						const snippet =
							valueStr.length > 200
								? valueStr.substring(0, 200) + '...'
								: valueStr;
						highlightedSnippet = `\n  Value: ${snippet}`;
					}
				} catch {
					// If we can't extract context, that's okay
				}
			}

			return `${
				index + 1
			}. At path "${path}": ${message}${highlightedSnippet}`;
		})
		.join('\n\n');
}

export type BlueprintValidationResult =
	| { valid: true }
	| { valid: false; errors: ErrorObject[] };

export function validateBlueprint(
	blueprintMaybe: object
): BlueprintValidationResult {
	const valid = blueprintValidator(blueprintMaybe);
	if (valid) {
		return { valid };
	}

	/**
	 * Each entry of "steps" can be either an object, null, undefined etc
	 * via the "anyOf" part of the schema.
	 *
	 * If the step has any error in it, like a missing property, Ajv will
	 * also return errors for each case listed in "anyOf" which means we'll
	 * learn that step is not a null, undefined etc. This is not helpful, so
	 * we filter out those errors.
	 *
	 * However, if the "anyOf" error is the only error we have then we want
	 * to keep it because the step is likely, say, a number, and we want to
	 * let the developer know.
	 */
	const hasErrorsDifferentThanAnyOf: Set<string> = new Set();
	for (const error of blueprintValidator.errors!) {
		if (!error.schemaPath.startsWith('#/properties/steps/items/anyOf')) {
			hasErrorsDifferentThanAnyOf.add(error.instancePath);
		}
	}
	const errors =
		blueprintValidator.errors?.filter(
			(error) =>
				!(
					error.schemaPath.startsWith(
						'#/properties/steps/items/anyOf'
					) && hasErrorsDifferentThanAnyOf.has(error.instancePath)
				)
		) ?? [];

	return {
		valid: false,
		errors,
	};
}

/**
 * Compiles a preferred version string into a supported version
 *
 * @param value The value to compile
 * @param supported The list of supported versions
 * @param latest The latest supported version
 * @returns The compiled version
 */
function compileVersion<T>(
	value: string | undefined | null,
	supported: readonly T[],
	latest: string
): T {
	if (value && supported.includes(value as any)) {
		return value as T;
	}
	return latest as T;
}

/**
 * Determines if a step is a StepDefinition object
 *
 * @param step The object to test
 * @returns Whether the object is a StepDefinition
 */
export function isStepDefinition(
	step: Step | string | undefined | false | null
): step is StepDefinition {
	return !!(typeof step === 'object' && step);
}

/**
 * Determines if a step is still supported, or was it deprecated
 * and removed.
 *
 * @param step The step definition to test.
 * @returns Whether the step is still supported.
 */
function isStepStillSupported(
	step: Record<string, any>
): step is StepDefinition {
	if (['setPhpIniEntry', 'request'].includes(step['step'])) {
		logger.warn(
			`The "${step['step']}" Blueprint is no longer supported and you can remove it from your Blueprint.`
		);
		return false;
	}
	return true;
}

interface CompileStepArgsOptions {
	/** Optional semaphore to control access to a shared resource */
	semaphore?: Semaphore;
	/** The root progress tracker for the compilation */
	rootProgressTracker: ProgressTracker;
	/** The total progress weight of all the steps in the blueprint */
	totalProgressWeight: number;
	/**
	 * Proxy URL to use for cross-origin requests.
	 *
	 * @see CompileBlueprintV1Options.corsProxy
	 */
	corsProxy?: string;
	/**
	 * A filesystem to use for the "blueprint" resource type.
	 */
	streamBundledFile?: StreamBundledFile;
	/**
	 * Additional headers to pass to git operations.
	 * A function that returns headers based on the URL being accessed.
	 */
	gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
}

/**
 * Compiles a single Blueprint step into a form that can be executed
 *
 * @param playground The PlaygroundClient to use for the compilation
 * @param step The step to compile
 * @param options Additional options for the compilation
 * @returns The compiled step
 */
function compileStep<S extends StepDefinition>(
	step: S,
	{
		semaphore,
		rootProgressTracker,
		totalProgressWeight,
		corsProxy,
		streamBundledFile,
		gitAdditionalHeadersCallback,
	}: CompileStepArgsOptions
): { run: CompiledV1Step; step: S; resources: Array<Resource<any>> } {
	const stepProgress = rootProgressTracker.stage(
		(step.progress?.weight || 1) / totalProgressWeight
	);

	const args: any = {};
	for (const key of Object.keys(step)) {
		let value = (step as any)[key];
		if (isResourceReference(value)) {
			value = Resource.create(value, {
				semaphore,
				corsProxy,
				streamBundledFile,
				gitAdditionalHeadersCallback,
			});
		}
		args[key] = value;
	}

	const run = async (playground: UniversalPHP) => {
		try {
			stepProgress.fillSlowly();
			return await keyedStepHandlers[step.step](
				playground,
				await resolveArguments(args),
				{
					tracker: stepProgress,
					initialCaption: step.progress?.caption,
				}
			);
		} finally {
			stepProgress.finish();
		}
	};

	/**
	 * The weight of each async resource is the same, and is the same as the
	 * weight of the step itself.
	 */
	const resources = getResources(args);
	const asyncResources = getResources(args).filter(
		(resource) => resource.isAsync
	);

	const evenWeight = 1 / (asyncResources.length + 1);
	for (const resource of asyncResources) {
		resource.progress = stepProgress.stage(evenWeight);
	}

	return { run, step, resources };
}

/**
 * Gets the resources used by a specific compiled step
 *
 * @param step The compiled step
 * @returns The resources used by the compiled step
 */
function getResources<S extends StepDefinition>(args: S) {
	const result: Resource<any>[] = [];
	for (const argName in args) {
		const resourceMaybe = (args as any)[argName];
		if (resourceMaybe instanceof Resource) {
			result.push(resourceMaybe);
		}
	}
	return result;
}

/**
 * Replaces Resource objects with their resolved values
 *
 * @param step The compiled step
 * @returns The resources used by the compiled step
 */
async function resolveArguments<T extends Record<string, unknown>>(args: T) {
	const resolved: any = {};
	for (const argName in args) {
		const resourceMaybe = (args as any)[argName];
		if (resourceMaybe instanceof Resource) {
			resolved[argName] = await resourceMaybe.resolve();
		} else {
			resolved[argName] = resourceMaybe;
		}
	}
	return resolved;
}

export async function runBlueprintV1Steps(
	compiledBlueprint: CompiledBlueprintV1,
	playground: UniversalPHP
) {
	await compiledBlueprint.run(playground);
}
